About
Overview This prototype data explorer tool provides provisional weekly death statistics for England and Wales from the Office for National Statistics (2025 onwards).
A death occurrence is the date someone has died. A death registration is when that death is registered. The time it takes for a death to be registered can vary for multiple reasons. Currently, mortality statistics are registration-based.
If you like this prototype data explorer and would like it to be maintained or developed please let us know!
For more information please see the Weekly Deaths Dashboard and user guide on the ONS website.
Time Series (2025 onwards)
Plotly = require("https://cdn.plot.ly/plotly-2.27.0.min.js")
// `death_data` is provided by the earlier R chunk which we passed into OJS via `ojs_define()`.
// `transpose()` essentially turns a "column-oriented" table into a row-wise array of objects.
death_data_raw = transpose(death_data)
// parse dates and numbers
/* IMPORTANT: parse and normalise fields
- JavaScript treats spreadsheet values as strings by default
- so we convert the week ending to a Date, numeric fields to numbers, etc...
- this keeps the rest of the code simpler and avoids type bugs.
*/
function layoutScale(w) {
const isNarrow = w < 600;
return {
isNarrow: isNarrow,
titleFont: { size: isNarrow ? 14 : 16 },
axisTitle: { size: isNarrow ? 12 : 14 },
// Time Series Margins
marginsTS: {
l: isNarrow ? 40 : 80,
r: 15,
t: isNarrow ? 30 : 60, // Reduced top (fixes blank space)
b: isNarrow ? 85 : 44 // Increased bottom (fits rotated dates)
},
// Bar Chart Margins
marginsBar: {
l: 60,
r: 20,
t: isNarrow ? 25 : 40,
b: isNarrow ? 80 : 40
},
standoffX: isNarrow ? 12 : 16,
standoffY: isNarrow ? 5 : 16,
tickAngleX: isNarrow ? -45 : 0
};
}
death_data_parsed = death_data_raw.map(d => ({
series: d.series, //registrations
week_ending: new Date(d.week_ending_str), // date object for plotly
week_number: +d.week_number, // force number
area_of_usual_residence: d.area_of_usual_residence, // region name
sex: d.sex, // male /female
age_band: d.age_band,
age_order: +d.age_order, // ordering key from R
number_of_deaths: +d.number_of_deaths // once again force number
}))
// unique values
unique_series = ["Registrations", "Occurrences"]
unique_weeks = Array.from(new Set(death_data_parsed.map(d => d.week_number))).sort((a,b)=>a-b)
unique_regions = Array.from(new Set(death_data_parsed.map(d => d.area_of_usual_residence))).sort()
unique_sex = ["Male", "Female"]
unique_ages = ["0-14 years","15-44 years","45-64 years","65-74 years","75-84 years","85+ years"]
// colours - hex codes copied from ons website
color_primary = "#003C57"
color_secondary = "#a8bd3a"
color_accent = "#00a3a6"
color_warning = "#206095"
// logic to switch colour based on sex selection
current_sex_color = {
// if both are selected (length 2) or nothing selected (length 0), use default
if (selected_sex.length === 2 || selected_sex.length === 0) {
return color_primary;
}
// if only Male is selected
else if (selected_sex.includes("Male")) {
return "#206095";
}
// if only Female is selected
else {
return "#00a3a6";
}
}
// create the default state: automatically show latest week selected for bar charts.
// we compute the most recent week number and use it as the default selection.
// NOTE: This affects the bar charts (which read from `filtered_data` below).
latestWeek = d3.max(unique_weeks)
// built-in inputs
// these create reactive controls. When a user touches a filter, these controls change the plot.
// dependent cells re-run automatically
viewof selected_series = Inputs.radio(unique_series, { label: html`Data Type`, value: "Registrations" })
viewof selected_sex = Inputs.checkbox(unique_sex, { label: html`Sex`, value: unique_sex })
// default to the latest week ONLY
//viewof selected_weeks = Inputs.select(
// unique_weeks,
// { label: html`Week Numbers`, multiple: true, value: [latestWeek], size: 8 }
//)
viewof selected_weeks = Inputs.select(
unique_weeks,
// Remove {multiple: true, size: 8} from the options object
// Set the value directly to the single latestWeek number
{ label: html`Week Number`, value: latestWeek }
)
/* shared filtered data for bar charts
here we filter by:
- selected series (Registrations vs Occurrences),
- the currently selected week or weeks,
- selected sex (Male/Female).
The time-series chart does NOT use this because
that line needs to show all weeks by default.
*/
//filtered_data = death_data_parsed.filter(d =>
// d.series === selected_series &&
// selected_weeks.includes(d.week_number) &&
// selected_sex.includes(d.sex)
//)
filtered_data = death_data_parsed.filter(d =>
d.series === selected_series &&
// MODIFIED: Use strict equality (== or ===) because selected_weeks is a single number, not an array
d.week_number === selected_weeks &&
selected_sex.includes(d.sex)
)
// format a date as "17 Oct 2025" and ensure UK style
formatWeekEnding = date =>
date?.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }) ?? "–"
// for the chart titles find the latest week-ending date present in the current selection.
// if multiple weeks are selected, this picks the most recent to show in the title
selected_week_end_date =
(filtered_data.length
? d3.max(filtered_data, d => d.week_ending)
: null)
selected_week_title = `Week ending ${formatWeekEnding(selected_week_end_date)}`
// dynamic titles for selected sex
// Determine sex label for title
sexTitle = (() => {
if (selected_sex.length === 2 || selected_sex.length === 0) {
return "";
} else if (selected_sex.includes("Male")) {
return "(Male)";
} else {
return "(Female)";
}
})();
// label: timeseries-chart
{
// build a dataset that that IGNORES selected_weeks so TS always shows all weeks
const ts_rows = death_data_parsed.filter(d =>
d.series === selected_series &&
selected_sex.includes(d.sex)
);
// aggregate by week (sum over all selected sexes)
let ts_map = d3.rollup(
ts_rows,
v => d3.sum(v, d => d.number_of_deaths),
d => d.week_ending.toISOString()
);
let ts_array = Array.from(ts_map, ([date, deaths]) => ({ date: new Date(date), deaths }))
.sort((a,b) => a.date - b.date);
if (selected_series === "Occurrences" && ts_array.length > 0) ts_array = ts_array.slice(0, -1);
const trace = {
x: ts_array.map(d => d.date),
y: ts_array.map(d => d.deaths),
type: 'scatter',
mode: 'lines+markers',
name: selected_series,
marker: { size: 8, color: selected_series === "Registrations" ? current_sex_color : color_secondary },
line: { width: 4, color: selected_series === "Registrations" ? current_sex_color : color_secondary },
hovertemplate: 'Week ending: %{x|%d %b %Y}<br>Deaths: %{y:,.0f}<extra></extra>'
};
const div = (this && this.style) ? this : DOM.element('div');
// hard-stop any transient overflow inside the card
div.style.width = '100%';
div.style.height = '100%';
// measure the container and pin the layout size to integer pixels
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
const scale = layoutScale(w);
const layout = {
title: {
text: `<b>Weekly Deaths - ${selected_series} ${sexTitle}</b>`,
// Use dynamic font size
font: { ...scale.titleFont, color: '#333', family: 'Open Sans, sans-serif' },
x: 0, xanchor: 'left'
},
xaxis: {
title: {
text: 'Week ending',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' },
standoff: scale.standoffX
},
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
showgrid: true,
gridcolor: '#f0f0f0',
automargin: false, // turn off automargin so our manual margins take precedence
tickangle: scale.tickAngleX
},
yaxis: {
title: {
text: 'Number of deaths',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' },
standoff: scale.standoffY
},
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
showgrid: true,
gridcolor: '#f0f0f0',
tickformat: ',d',
automargin: true
},
plot_bgcolor: '#ffffff',
paper_bgcolor: '#ffffff',
margin: scale.marginsTS,
autosize: false,
width: w,
height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [trace], layout, config);
// keep responsiveness: if the card resizes, update the plot size to the new integer pixels
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}Deaths by Region
regional_totals = d3.rollup(filtered_data, v => d3.sum(v, d => d.number_of_deaths), d => d.area_of_usual_residence)
regional_array = Array.from(regional_totals, ([region, deaths]) => ({ region, deaths }))
.sort((a, b) => b.deaths - a.deaths)
{
const trace = {
y: regional_array.map(d => d.region),
x: regional_array.map(d => d.deaths),
type: 'bar', orientation: 'h',
marker: { color: current_sex_color, line: { color: '#333', width: 1 } },
hovertemplate: '%{y}<br>Deaths: %{x:,.0f}<extra></extra>'
};
const div = (this && this.style) ? this : DOM.element('div');
div.style.width = '100%';
div.style.height = '100%';
div.style.overflow = 'hidden';
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
// Estimate left margin from longest label (characters × 8px + base)
const longest = regional_array.reduce((m, d) => Math.max(m, d.region.length), 0);
const leftMargin = Math.min(180, Math.max(60, 8 * longest + 28));
const scale = layoutScale(w);
const layout = {
title: {
text: `<b>${selected_series} by Region (Week ${selected_weeks})</b>`, // Shortened title for mobile
font: { ...scale.titleFont, family: 'Open Sans, sans-serif', color: '#333333' },
x: 0, xanchor: 'left'
},
xaxis: {
title: {
text: 'Number of deaths',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' }
},
tickfont: { family: 'Open Sans, sans-serif', size: 11},
showgrid: true,
gridcolor: '#f0f0f0'
},
yaxis: {
title: '',
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
automargin: true
},
// We use the dynamic 'leftMargin' you calculated, but fix T and B
margin: {
t: scale.marginsBar.t,
// Override the big bottom margin from scale, we don't need it here
b: scale.isNarrow ? 45 : scale.marginsBar.b,
l: leftMargin,
r: scale.marginsBar.r
},
plot_bgcolor: '#ffffff',
paper_bgcolor: '#ffffff',
autosize: false,
width: w,
height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [trace], layout, config);
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}Deaths by Age and Sex
age_order_key = age => {
const m = /(\d+)/.exec(age || "");
return m ? +m[1] : Number.POSITIVE_INFINITY;
}
// determine the order of age bands on the Y axis.
// if a global list `unique_ages` exists (defined elsewhere), use that order;
// otherwise, derive the distinct set of age bands from the current data and
// sort them using the numeric key above (youngest first).
ordered_ages = (typeof unique_ages !== "undefined" && Array.isArray(unique_ages) && unique_ages.length)
? unique_ages.slice()
: Array.from(new Set((filtered_data || []).map(d => d.age_band))).sort((a,b) => age_order_key(a) - age_order_key(b))
// we aggregate deaths by age band and sex for the current selection.
// `filtered_data` already applies the filters (series, sex, and the selected week).
age_sex_pyramid_data = {
// defensive default: if filtered_data is missing then fall back to an empty array.
const rows = filtered_data || [];
// d3.rollup essentially groups rows first by age band, then by sex, and sums deaths
// the result is a nested map: Map(age_band -> Map(sex -> total_deaths))
const rolled = d3.rollup(rows, v => d3.sum(v, d => +(d.number_of_deaths || 0)), d => d.age_band, d => d.sex);
// now we populate two arrays for plotly:
// - `male` with negative values (so bars appear on the left),
// - `female` with positive values (bars to the right)
// here also "track" totals to compute percentages in tooltips
const displayLabel = age => age.replace('-', ' to ');
const order = ordered_ages;
const male = []; const female = [];
let total = 0; const totalBySex = new Map([["Male",0],["Female",0]]);
// now loop through age bands in display order and pull out male/female totals
for (const age of order) {
// get the inner map for this age band, or an empty one if absent
const bySex = rolled.get(age) || new Map();
const m = +(bySex.get("Male") || 0);
const f = +(bySex.get("Female") || 0);
// update totals used for percentage calculations
total += (m+f);
totalBySex.set("Male", totalBySex.get("Male") + m);
totalBySex.set("Female", totalBySex.get("Female") + f);
male.push({ age, value: -m, abs: m });
female.push({ age, value: f, abs: f });
}
return { order, male, female, total, totalBySex };
}
{
const { order, male, female, total, totalBySex } = age_sex_pyramid_data;
const div = (this && this.style) ? this : DOM.element('div');
div.style.width = '100%';
div.style.height = '100%';
// Early exit remains unchanged
const maxAbs = Math.max(
d3.max(male, d => d?.abs || 0) || 0,
d3.max(female, d => d?.abs || 0) || 0
);
if (!order.length || maxAbs === 0) {
div.innerHTML = "<div style='display:flex;align-items:center;justify-content:center;height:100%;color:#666;'><em>selection empty.</em></div>";
return div;
}
if (div.innerHTML.includes("selection empty")) div.innerHTML = "";
const niceMax = d3.ticks(0, maxAbs * 1.1, 5).slice(-1)[0] || maxAbs;
const posTicks = d3.ticks(0, niceMax, 5).slice(1);
const tickvals = [...posTicks.map(t => -t), 0, ...posTicks];
const ticktext = tickvals.map(v => d3.format(",d")(Math.abs(v)));
const mTotal = totalBySex.get("Male") || 0;
const fTotal = totalBySex.get("Female") || 0;
const mkCD = d => [d.abs, total ? (d.abs/total*100) : 0, mTotal ? (d.abs/mTotal*100) : 0];
const fkCD = d => [d.abs, total ? (d.abs/total*100) : 0, fTotal ? (d.abs/fTotal*100) : 0];
const displayLabel = age => age.replace('-', ' to ');
const maleTrace = {
y: order.map(displayLabel), x: male.map(d => d.value), customdata: male.map(mkCD),
name: "Male", type: "bar", orientation: "h", marker: { color: "#206095" },
hovertemplate: "%{y}<br>Male deaths: %{customdata[0]:,.0f}<br>Share of total: %{customdata[1]:.1f}%<br>Within male: %{customdata[2]:.1f}%<extra></extra>"
};
const femaleTrace = {
y: order.map(displayLabel), x: female.map(d => d.value), customdata: female.map(fkCD),
name: "Female", type: "bar", orientation: "h", marker: { color: "#00a3a6" },
hovertemplate: "%{y}<br>Female deaths: %{customdata[0]:,.0f}<br>Share of total: %{customdata[1]:.1f}%<br>Within female: %{customdata[2]:.1f}%<extra></extra>"
};
const rect = div.getBoundingClientRect();
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
const scale = layoutScale(w);
const layout = {
title: {
text: `<b>${selected_series} for ${selected_week_title} (Week ${selected_weeks})</b>`,
font: { color: '#333333', size: scale.titleFont.size }, // use scaled font size
x: 0,
xanchor: 'left'
},
barmode: "overlay",
xaxis: {
title: {
text: 'Number of deaths',
font: { ...scale.axisTitle, family: 'Open Sans, sans-serif' }
},
tickfont: { family: 'Open Sans, sans-serif', size: 12 },
range: [-niceMax, niceMax],
tickvals,
ticktext,
zeroline: true,
zerolinecolor: "#555",
zerolinewidth: 1.5,
gridcolor: "rgba(0,0,0,0.06)",
automargin: true
},
yaxis: {
title: "",
tickfont: { family: 'Open Sans, sans-serif', size: 13 },
categoryorder: "array",
categoryarray: order.map(displayLabel),
automargin: true
},
// UPDATED LEGEND POSITIONING
legend: {
orientation: "h",
x: 0.5,
xanchor: 'center', // center the legend horizontally
y: -0.18, // push lower on small screens
yanchor: 'top'
},
// use the dynamic margins defined in Step 1
margin: {
t: scale.marginsBar.t,
b: 20,
l: 40,
r: 40
},
plot_bgcolor: "rgba(0,0,0,0)",
paper_bgcolor: "rgba(0,0,0,0)",
autosize: false,
width: w,
height: h
};
const config = { displayModeBar: false, responsive: true };
Plotly.react(div, [maleTrace, femaleTrace], layout, config);
if (!div._ro) {
div._ro = new ResizeObserver(entries => {
const cr = entries[0].contentRect;
Plotly.relayout(div, { width: Math.floor(cr.width), height: Math.floor(cr.height) });
});
div._ro.observe(div);
}
return div;
}